useQuery 구현하기 2탄 | 커스텀 useQuery 구현
2023. 10. 08.
#리액트
useQuery 구현하기 1탄에서 이어지는 글입니다.
📄 사전 지식
useQuery 를 구현하기 전에 직접 구현할 useQuery 에서 사용할 정보에 대해 간단하게 알아보자
const { data, // 응답 데이터 error, // 에러 내용 isError, // 에러 상태 isLoading, // 로딩 상태 isSuccess, // 성공 상태 refetch, // refetch 함수 } = useQuery({ queryKey, queryFn, // fetcher cacheTime, // 얘도 있으면 좋을듯! enabled, // 얘도 있으면 좋을듯! onError, onSettled, onSuccess, suspense, // useErrorBoundary, // 얘랑 같이 사용하는 듯! suspense 로 })
위와 같이 react-query 의 useQuery 는 정의되어 있다.
이번에 구현할
useQuery
는 위 Interface 와 유사하지만 약간 다르게 정의해서 사용했다커스텀 useQuery Interface
const { isLoading, // 로딩 상태 data, // 응답 데이터 error, // 에러 내용 } = useQuery(queryFn, { queryKey, enabled, onSuccess, onError, suspense, })
위와 같이 적용했다.
🚀 구현한 UI
- useQuery 를 이용해서 총 2개의 API 를 요청해서 데이터를 렌더링한다.
- 여러 개의 Image List 를 요청후 렌더링하고
- 해당 아이템을 클릭하면 선택한 아이템의 정보를 요청해서 렌더링하는 구조이다.
👨🏻💻 간단한 useQuery 구현하기
자세한 코드는 여기를 참고 (캐시까지 적용된 코드)
1탄에서 구현한 useFetch 를 기반으로 useQuery 를 구현하였다!
import { useCallback, useEffect, useState } from 'react'; export function useQuery<T>(fetcher: () => Promise<T>, { queryKey, ...option }: UseQueryOption<T>) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const [data, setData] = useState<T>(); const { enabled = true } = option; const fetch = useCallback(async () => { setIsLoading(true); setError(null); try { const data = await fetcher(); setData(data); option.onSuccess && option.onSuccess(); } catch (error) { if (error instanceof Error) { setError(error); } option.onError && option.onError(error); } finally { setIsLoading(false); } }, [fetcher]); useEffect(() => { if (!enabled) return; fetch(); }, [enabled, JSON.stringify(queryKey)]); return { isLoading: isLoading, data, error, }; } type UseQueryOption<T> = { queryKey: unknown[]; enabled?: boolean; onSuccess?: (data?: T) => void; onError?: (error?: unknown) => void; };
핵심 로직 1 - fetch 함수
const fetch = useCallback(async () => { setIsLoading(true); setError(null); try { const data = await fetcher(); setData(data); option.onSuccess && option.onSuccess(); } catch (error) { if (error instanceof Error) { setError(error); } option.onError && option.onError(error); } finally { setIsLoading(false); } }, [fetcher]);
- 인자로
fetcher
를 받아 loading 중인지, 성공 실패 인지에 따라 data 및 error 업데이트 하는 로직을 처리한다.
⇒ 1탄에서 useFetch 의 기본 내용과 비슷하다
- 다른 점이 있다면 option 으로 전달 받은 onSuccess 와 onError 를 호출하여 처리할 수 있게 하였다.
핵심 로직 2 - fetch 함수를 호출하는 기준!
useEffect(() => { if (!enabled) return; fetch(); }, [enabled, JSON.stringify(queryKey)]);
- option 으로 enabled 를 받아서 enabled 가 true 인 경우만 호출하도록 하였다.
- 또한, queryKey 가 달라지면 호출되어야 하기에 queryKey 도 의존성으로 추가했다.
⇒ 이때 queryKey가 배열로 오기 때문에 JSON.stringify 로 변경하여 변경 여부를 좀 더 쉽게 처리했다.
👨🏻💻 캐시 적용
캐시 코드
export class Cache<Key, Value> { private cache: Map<Key, Value>; constructor() { this.cache = new Map<Key, Value>(); } public set(key: Key, value: Value): void { this.cache.set(key, value); } public get(key: Key): Value | undefined { return this.cache.get(key); } public has(key: Key): boolean { return this.cache.has(key); } public delete(key: Key): void { this.cache.delete(key); } // ... }
- 캐시를 사용하기 위해 캐시를 클래스로 구현하였다.
이 중에
get
, set
, has
만 사용 하였다캐시를 적용한 useQuery Hook
import { useCallback, useEffect, useState } from 'react'; import { Cache } from '@/example2/utils/cache.ts'; export function useQuery<T>(fetcher: () => Promise<T>, { queryKey, ...option }: UseQueryOption<T>) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const [data, setData] = useState<T>(); const { enabled = true } = option; const [cache] = useState(new Cache<string, T>()); const 쿼리키 = JSON.stringify(queryKey); const fetch = useCallback(async () => { setIsLoading(true); setError(null); try { const data = await fetcher(); setData(data); cache.set(쿼리키, data); option.onSuccess && option.onSuccess(); } catch (error) { if (error instanceof Error) { setError(error); } option.onError && option.onError(error); } finally { setIsLoading(false); } }, [fetcher]); useEffect(() => { if (!enabled) return; if (cache.has(쿼리키)) return; fetch(); }, [enabled, 쿼리키]); const cachedData = cache.get(쿼리키); return { isLoading: isLoading, data: cachedData ?? data, error, }; } type UseQueryOption<T> = { queryKey: unknown[]; enabled?: boolean; onSuccess?: (data?: T) => void; onError?: (error?: unknown) => void; };
핵심 로직 1 - 캐시
const [cache] = useState(new Cache<string, T>()); const 쿼리키 = JSON.stringify(queryKey);
- Cache 클래스를 이용하여 cache 를 정의하고 이를 useState 로 상태로 관리했다.
- Cache 클래스에 타입을 지정하고자 했고
- react 에서 상태로 관리하는게 더 적합하다고 생각했다.
해당 구문을 모듈단 (useQuery 커스텀 훅 외부)에서 가능하지만
useQuery 외부에 선언시 타입 지정(추론) 불가
- 전달 받는 queryKey 는 배열형태이지만, 캐시와 의존성에서 배열보다는 문자열로 관리하는게 더 간편하고 해당 부분을 자주 사용하여 queryKey 를 문자열로 변환하여
쿼리키
변수로 관리했다.
핵심 로직 2 - 캐시 적용 및 서비스 로직
const fetch = useCallback(async () => { setIsLoading(true); setError(null); try { const data = await fetcher(); setData(data); cache.set(쿼리키, data); option.onSuccess && option.onSuccess(); } catch (error) { if (error instanceof Error) { setError(error); } option.onError && option.onError(error); } finally { setIsLoading(false); } }, [fetcher]); useEffect(() => { if (!enabled) return; if (cache.has(쿼리키)) return; fetch(); }, [enabled, 쿼리키]); const cachedData = cache.get(쿼리키); return { isLoading: isLoading, data: cachedData ?? data, error, };
- fetch 함수 안에서는
성공한 경우 해당 data 를 cache 에 저장하여 재요청시 캐시 값을 사용할 수 있도록 설정했다.
const data = await fetcher(); setData(data); cache.set(쿼리키, data);
- useEffect() 에서 이미 cache 가 있는 경우 새로 API 를 요청하지 안도록 설정했다,
if (cache.has(쿼리키)) return;
- 값을 조회할 때 캐시된 데이터가 있다면 해당 데이터를 먼저 반환하도록 하였다.
const cachedData = cache.get(쿼리키); return { isLoading: isLoading, data: cachedData ?? data, error, };
캐시 적용 결과 화면
기존에는 캐시가 걸려있지 않아 모든 요청마다 API 를 호출하였는데 캐시가 걸린 경우 API 를 따로 호출하지 않는 것을 확인할 수 있다.
- 해당 결과물을 확인하면 처음 요청시에는 API 를 통해 데이터를 호출하고 (1, 2)
이후에 다시 클릭하면 (1, 2) API 요청을 하지 않고 기존에 있는 데이터를 사용한다.
그 후 새로운 API (5) 를 요청하면 최초이기 때문에 해당 데이터를 다시 요청하고
🚀 Suspense / ErrorBoundary 적용
리액트 18 부터 Suspense 와 ErrorBoundary 를 지원하고 있다 하지만, axios 를 사용해서 Suspense 를 사용하기 어려운데, 왜 안되는지 그리고 이를 어떻게 하면 사용할 수 있는지를 useQuery 를 구현해 보면서 알아보자
React 의 Suspense를 사용하면 컴포넌트의 랜더링을 어떤 작업이 끝날 때까지 잠시 중단시키고 다른 컴포넌트를 먼저 랜더링할 수 있다
⇒ 일반적으로 API 요청하는 컴포넌트가 있는 경우 API 요청시에는 로딩 UI (다른 컴포넌트를 먼저 렌더링) 를 먼저 렌더링하고 요청이 완료되면 해당 컴포넌트를 렌더링하도록 Suspense 를 통해 구현할 수 있음!
구현
자세한 코드는 여기를 참고
PromiseWrapper
utils/promiseWrapper.ts
/** * @link https://deadsimplechat.com/blog/react-suspense/ */ export function promiseWrapper<T>(promise: Promise<T>) { let status = 'pending'; let result: T; const s = promise.then( (value) => { status = 'success'; result = value; }, (error) => { status = 'error'; result = error; }, ); return () => { switch (status) { case 'pending': throw s; case 'success': return result; case 'error': throw result; default: throw new Error('Unknown status'); } }; }
- React Suspens 와 함께 작동하도록 Axios 요청을 래핑하는 유틸리티 메서드를 만들었다
pending
상태
Promise 자체를 반환해서 Suspense가 이를 보고 fallback component 를 반한하도록 Promise 를 반환하도록 구성
success
상태Promise 의 반환 값을 반환한다.
error
상태 에러를 반환
이로 인해 Suspense 는 성공이면 Promise 반환 값을, 로딩이면 Promise 가 반환되기에 Suspense 에서 처리될 수 있고, error 인 경우 Error 가 반환되어 ErrorBoundary 에서 처리될 수 있다.
useQuery
exmple3/hooks/useQuery.ts
import { useCallback, useEffect, useState } from 'react'; import { Cache } from '../utils/cache'; import { promiseWrapper } from '../utils/promiseWrapper.ts'; export function useQuery<T>(fetcher: () => Promise<T>, { queryKey, ...option }: UseQueryOption<T>) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const [data, setData] = useState<T>(); const { enabled = true, suspense = false } = option; const [cache] = useState(new Cache<string, T>()); const 쿼리키 = JSON.stringify(queryKey); // 캐시 - 일반인 경우 useEffect(() => { if (!enabled) return; if (cache.has(쿼리키)) return; fetch(); }, [enabled, 쿼리키]); // suspense 관련 로직 useEffect(() => { if (!enabled) return; if (!suspense) return; if (!data) return; if (cache.has(쿼리키)) return; cache.set(쿼리키, data); // 위 useEffect 에서 쿼리키에 따라 호출 여부를 판단했고 // 해당 부분은 suspenseWrapper 에서 올바른 결과값을 할당하기 위해 처리 }, [data]); const fetch = useCallback(async () => { setIsLoading(true); setError(null); try { if (suspense) { // suspense 처리 로직 const promise = fetcher(); setData(promiseWrapper(promise)); } else { // 일반 로직 - suspense 처리 로직 이 아닌 경우 const data = await fetcher(); setData(data); cache.set(쿼리키, data); } option.onSuccess && option.onSuccess(); } catch (error) { if (error instanceof Error) { setError(error); } option.onError && option.onError(error); } finally { setIsLoading(false); } }, [fetcher]); const cachedData = cache.get(쿼리키); return { isLoading: isLoading, data: cachedData ?? data, error, }; } type UseQueryOption<T> = { queryKey: unknown[]; enabled?: boolean; onSuccess?: (data?: T) => void; onError?: (error?: unknown) => void; suspense?: boolean; };
- 기존 코드와 크게 달라진것 없이 suspens 를 처리하기 위한 props 및 관련 로직을 추가했다.
- 그리고 suspense 인 경우 처리하기 위해 useEffect 와 fetch 함수에 로직을 추가했다.
// suspense 관련 로직 useEffect(() => { if (!enabled) return; if (!suspense) return; if (!data) return; if (cache.has(쿼리키)) return; cache.set(쿼리키, data); // 위 useEffect 에서 쿼리키에 따라 호출 여부를 판단했고 // 해당 부분은 suspenseWrapper 에서 올바른 결과값을 할당하기 위해 처리 }, [data]);
fetch 함수에서
setData(promiseWrapper(promise));
와 같이 promiseWrapper 에서 정상적인 경우 응답 data 를 반환하기에 의존성을 줘서 올바른 data 가 올 경우 cache 에 등록하도록 하였다.const fetch = useCallback(async () => { // ... try { if (suspense) { // suspense 처리 로직 const promise = fetcher(); setData(promiseWrapper(promise)); } else { // ... }, [fetcher]);
여기까지 useQuery 를 간단하게 구현하고 캐시 및 Suspense 까지 적용해봤다.
추가적으로 ErrorBoudary 도 적용해 볼 수 있다. ErrorBoundary 도 PromiseWrapper 에서 error 인 경우도 처리되어있기 때문에 위 코드를 잘 이용해서하면 쉽게 구현할 수 있다.
이제 마지막으로 useInfiniteQuery 를 간단하게 구현해보자!